Tasks, microtasks, queues and schedules

本文翻译自:原文地址

开始

看下列一段JavaScript代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('script start');

setTimeout(function() {
console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});

console.log('script end');

上述代码会打印什么呢?

正确答案是: script start , promise1 , script end , promise2 , setTimeout ,但是对于浏览器的支持度而言缺非常混乱。

Microsoft Edge, Firefox 40, iOS Safari和桌面版Safari 8.0.8会有时把setTimeout先于promise1promise2打印,这看起来像是在竞赛一样,另外,Firefox 39 和 Safari 8.0.7则表现正常.

为什么会出现这种现象呢?

为了理解为什么会发生这种现象则必须理解任务和宏任务,在你第一次遇见它时,k额能会让你大吃一惊,深呼吸。。。

每个线程都有自己的时间循环,所以每个web worker都能独立运行,而同一来源的所有窗口共享一个事件循环,因此它们可以进行同步的通信。事件循环不断的运行,运行队列中的任务任务。事件循环有多个任务源,可以保证源中的执行顺序。但是浏览器会在循环的每个回合中选择从哪个源获取任务。这允许浏览器优先选择性能敏感的任务比如说用户输入。

任务是有顺序的以便浏览器可以从内部进入JavaScript/DOM域并且确保这些行为有序执行(这有什么因果关系呢。。。)。在任务之间,浏览器可以渲染更新。从鼠标点击到事件回调需要调度任务,解析HTML也是如此,在上面的实例中也是一样,setTimeout

setTimeout等待给定的延迟事件后为回调函数调度一个新的任务,这就是为什么setTimeoutscript end之后打印出来。打印script end是第一个任务的一部分,script在一个单独的任务中打印。了解这些是不够的,接下来。。。

微任务(Microtasks)通常发生在当前正在执行的脚本之后执行,例如对一批动作做出反应,或者使某些异步执行而不需要承担整个新任务的代价(emmmm…)。只要没有其他的js代码在执行中,在每个任务的末尾,微任务在回调之后被处理。在微任务期间,任何新加的微任务都会添加到队列的默认并执行,微任务包括mutation observer回调,在上述例子中即是Promise回调。

一旦一个promsie被解决了,或者已经被解决了,它就为回调(reactionary callbacks)排队一个微任务。这保证了promise的回调是异步的即使promise已经得到解决。因此对已确定的promise调用then会立即使微任务排队。这是为什么promsie1promise2script end之后打印,而当前正在运行的脚本必须在微任务处理后才完成。promise1promise2setTimeout之前打印,因为微任务通常发生在下一个任务之前。

一步一步来:

1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('script start');

setTimeout(function() {
console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});

console.log('script end');

执行步骤:

  1. 打印script start
  2. setTimeout的回调作为任务排队
  3. promise回调作为微任务排队
  4. 打印script end
  5. 在任务的末尾,开始执行微任务
  6. 打印promise1
  7. 第一个then执行完毕返回undefined,将第二个then的回调作为微任务排队
  8. 第一个微任务完成,执行队列中下一个微任务
  9. 打印promise2
  10. 任务执行完毕,浏览器能更新渲染啦
  11. 打印setTimeout
  12. 完毕

为什么浏览器的表现会不一致?

一些浏览器打印script start, script end, setTimeout, promise1, promise2,它们在setTimeout之后执行promise回调,就好像它们把promise回调当做一个新的任务而不是微任务。

这是可以被原谅的,因为promise来自ECMAScript而不是HTML,ECMAScript有类似于微任务的概念‘jobs’,但是除了模糊的邮件列表讨论,它们的关系并不明确。然而普遍的共识是promise是微任务队列的一部分,并且有很好的理由。

将promise视为任务会导致性能问题,因为回调会因为一些任务相关的事情比如说渲染造成不必要的延迟。它还会由于和其他的任务源的交互导致不确定性,并且可能终端和其他API的交互,稍后再做介绍。

WebKit一直是正确的,所以我任务Safari最终会修复这个问题,似乎在Firefox 43中修复的。

如何判断是任务还是微任务

测试时一种方法,看他和promise和setTimeout之间是如何打印的,尽管这依赖于浏览器的实现。

另外一种方法就是查看说明书,例如,setTimeout队列(setTimeout queues)是一个任务,而setTimeout队列(setTimeout queues)是一个微任务(microtask)。

正如之前提到的,在ECMAScript领域中,它们称微任务为‘jobs’,在 PerforemPromiseThen中,EnqueueJob被调用来排队一个微任务。

接下来,让我们看更多复杂的例子。

Level 1

1
2
3
<div class="outer">
<div class="inner"></div>
</div>

当我点击div.inner时会打印什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Let's listen for attribute changes on the
// outer element
new MutationObserver(function() {
console.log('mutate');
}).observe(outer, {
attributes: true
});

// Here's a click listener…
function onClick() {
console.log('click');

setTimeout(function() {
console.log('timeout');
}, 0);

Promise.resolve().then(function() {
console.log('promise');
});

outer.setAttribute('data-random', Math.random());
}

// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);

在各浏览器中执行的顺序如下:

chrome:

  1. click
  2. promise
  3. mutate
  4. click
  5. promise
  6. mutate
  7. timeout
  8. timeout

FireFox

  1. click
  2. mutate
  3. click
  4. mutate
  5. timeout
  6. promise
  7. promise
  8. timeout

safari

  1. click
  2. mutate
  3. click
  4. mutate
  5. promise
  6. promise
  7. timeout
  8. timeout

IE

  1. click
  2. click
  3. mutate
  4. timeout
  5. promise
  6. timeout
  7. promise

谁是正确的呢?

调度单单击事件是一个任务,Mutatation Observer和promise回调作为微任务排队。setTimeout回调作为任务排队,所以正确的应该是这样:

  1. click事件得到执行
  2. 打印click
  3. setTimeout回调添加至任务队列
  4. promise回调添加至微任务队列
  5. mutation添加至微任务队列
  6. click执行完毕
  7. 虽然现在处在任务中间,但如果堆栈为空,则在回调后处理微任务
  8. 打印promise
  9. 打印mutate(这里执行顺序应该是添加至微任务里时的顺序)
  10. 事件冒泡,所以回调再次执行
  11. 执行顺序2-9
  12. 打印timeout
  13. 打印timeout
  14. 完毕

但是这里我有个以为,既然两个click属于两个task,那么setTimeout会在最后执行,难道是setTimeout的最小时间间隔为4ms的缘故?

所以chrome有时正确的,微任务会在回调之后执行(只要没有其他的js在执行),我认为它限制在任务的结束(意思应该是在任务的结束执行),

浏览器出了什么问题?

Firefox和Safari在两个click事件间正确的执行了所有的微任务,正如展示的mutation回调,但是promsie看起来执行的不太一样,考虑到jobs和微任务的模糊关系,这是可以被原谅的,但是仍然希望他们执行在两个click事件中间。

对于Edge来讲,我们早已发现他的promsie队列的不正确,但是他也没有在两个click事件之间执行微任务,而是在所有的listeners执行完毕,这是为什么mutate的打印在click打印之后的缘故。

接下来的测试

用上面的实例,执行下列代码,将会发生什么

1
inner.click();

结构又不一样了:

chrome Firefox safari IE Edge
click click click click
click click click click
promise mutate mutate mutate
mutate timeout promise timeout
promise promise pormise promise
timeout promise timeout timeout
timeout promise promise promise

这又是发生了什么?

那么它应该发生什么:

  1. click事件得到执行
  2. 打印click
  3. setTimeout添加至任务队列
  4. promise添加至微任务队列
  5. mutation添加至微任务队列
  6. (注意)这里不能执行微任务,因为调用堆栈不是空的
  7. 下一个click事件得到执行
  8. 执行2-4
  9. 这里不会再次添加mutation到微任务队列,因为已经有一个pending状态的mutation
  10. 执行微任务
  11. 打印promise
  12. 打印mutate
  13. 打印promise(再次证明回调是按添加的顺序执行的)
  14. 执行setTimeout
  15. 执行setTimeout

所以chrome再次正确,这里和上面的区别好像是用户手动点击会因为冒泡而将两个事件分为两个task,而代码执行时是一个task所以中间不会执行微任务。

之前,这意味着微任务在侦听器回调之间执行,但是.click()会使事件的分发变成同步的,所以脚本在执行.click()后在两个回调间仍然在调用堆栈内。上述规则确保了微任务不会打断JavaScrit的执行。这就意味着微任务会在所有的listeners执行后再执行。

这会造成什么影响吗?

译者认为,则不会造成什么影响,只要你理解其中的原理,并正确的使用。。。

总结

  1. 任务会有序的执行,并且浏览器可以在期间执行渲染等任务
  2. 微任务也是有序的执行,并且执行在:
    只要没有其他脚本执行的每一个回调后
    在每一个task的结尾
  3. 自行执行click会将冒泡的过程变成同步的